Pinvon's Blog

所见, 所闻, 所思, 所想

Go实战(二) 程序结构

学习自: Go 语言圣经

声明

声明语句定义了程序的各种实体对象以及部分或全部属性. Go 用于声明的关键字有 var(变量), const(常量), type(类型), func(函数). 如:

package main
import "fmt"
const boilingF = 212.0
func main() {
    var f = boilingF
    var c = (f - 32) * 5 / 9
    fmt.Printf("boiling point = %g°F or %g°C\n", f, c)
    // Output:
    // boiling point = 212°F or 100°C
}

其中, boilingF 是在包级别范围声明的, f 和 c 是在函数内部声明的.

包级别范围声明的名字, 在整个包对应的每个源文件中都可以访问, 而不仅仅在声明语句所在的源文件.

函数内部声明的名字, 其生命周期仅在函数内部.

变量

变量的声明

变量的声明, 可以有以下三种形式:

var name type = expression
var name type  // 初始化成该类型对应的零值, 如数值型是0, 布尔型是false, 接口类型是nil
var name = expression  // 根据右值来推导左边的类型

通过第二种形式的声明, 我们可以知道, 在 Go 中不存在未初始化的变量.

声明多个变量

var i, j, k int                 // int, int, int
var b, f, s = true, 2.3, "four" // bool, float64, string

第一种形式需要所有变量的类型相同; 第二种形式中, 每个变量的类型由相应的右值推导.

注意, 多个变量的赋值, 是将右边表达式的值赋给左边对应位置的名字:

i, j = j, i  // 交换 i 和 j 的值

简短变量声明

简短变量声明语句的形式可用于声明和初始化局部变量. 形式为:

name := expression

变量的类型根据表达式来自动推导.

简短变量声明广泛应用于大部分的局部变量的声明和初始化中.

var 形式的声明语句常用于需要显式指定变量类型的地方, 或变量因稍后会被重新赋值而初始值无关紧要的地方.

i := 100                  // an int
var boiling float64 = 100 // a float64
var names []string
var err error
var p Point

简短变量声明语句, 也可以同时声明多个变量: i, j := 0, 1

左边的变量, 也可以不必全都是刚声明的变量, 但必须至少有一个是, 如:

// 可以通过编译, 因为 in 和 out 都是刚声明的变量
in, err := os.Open(infile)
out, err := os.Create(outfile)

// 不可以通过编译, 因为在第二个声明中, f 和 err 都在之前声明过了
f, err := os.Open(infile)
f, err := os.Create(outfile) // compile error: no new variables

第二种情况, 解决的办法就是把 := 改成 =.

使用简短变量声明可以省略 var 和 type, 但只能用在函数内部, 在函数外部则无法通过编译, 所以一般用 var 方式来定义全局变量.

_ 变量

_ 是个特殊的变量名, 任何赋予它的值都会被丢弃.

指针

指针存放的是另一个变量的地址, 它对应变量在内存中的存储位置. 我们可以通过指针来直接读取或更新对应变量的值, 而不必知道变量的名字.

先了解 & 和 * 操作符:

  • &x: 取变量 x 的内存地址
  • *p: p指针指向的变量的值

通过以下例子了解指针最基本的用法:

x := 1
p := &x         // p, of type *int, points to x
fmt.Println(*p) // "1"
*p = 2          // equivalent to x = 2
fmt.Println(x)  // "2"

指针的零值是 nil, 因此, 如果声明时未初始化, 指针的值就是 nil. 我们经常会在 if 中用到 p!=nil, 来判断该指针是否指向一个有意义的变量.

两个指针如果指向同一个变量, 或都为 nil 时, 才会相等:

var x, y int
fmt.Println(&x == &x, &x == &y, &x == nil) // "true false false"

返回局部变量的地址

在 Go 语言中, 可以返回局部变量的地址:

var p = f()
func f() *int {
    v := 1
    return &v
}

指针作为函数参数

因为指针包含变量的地址, 所以如果将指针作为参数传递给函数, 在函数中就可以通过该指针来更新变量的值. 如:

func incr(p *int) int {
    *p++ // 非常重要:只是增加p指向的变量的值,并不改变p指针!!!
    return *p
}

v := 1
incr(&v)              // side effect: v is now 2
fmt.Println(incr(&v)) // "3" (and v is 3)

对一个变量取地址, 复制指针, 都是为原变量创建了一个别名. 如, 在上面的例子中, *p 就是变量 v 的别名.

new()

表达式 new(T) 将会创建一个 T 类型的匿名变量, 初始化为 T 类型的零值, 然后返回变量地址, 返回的指针类型为 *T. 如:

p := new(int)   // p, *int 类型, 指向匿名的 int 变量
fmt.Println(*p) // "0"
*p = 2          // 设置 int 匿名变量的值为 2
fmt.Println(*p) // "2"

下面两种用法的效果是相同的:

func newInt() *int {
    return new(int)
}

func newInt() *int {
    var dummy int
    return &dummy
}

变量的生命周期

变量的生命周期是指在程序运行期间变量有效存在的时间间隔.

包一级声明的变量, 生命周期和整个程序运行周期是一致的.

局部变量: 从声明语句开始, 直到该变量不再被引用为止, 然后变量的存储空间可能被回收.

Go 的自动垃圾回收器的基本实现思路: 从每个包级的变量和每个当前运行函数的每个局部变量开始, 通过指针或引用的访问路径遍历, 是否可以找到该变量. 如果不存在这样的访问路径, 则说明该变量不可达, 也就说明它是否存在, 并不会影响程序后续的计算结果.

所以, 本质上来说, 一个变量的有效期只取决于该变量是否可达. 所以, 局部变量可能在函数返回之后依然存在, 另外, 变量分配在堆还是栈上, 由编译器自己决定. 如下所示:

var global *int

func f() {
    var x int
    x = 1
    global = &x
}

func g() {
    y := new(int)
    *y = 1
}

解析:

f() 中的变量 x 必须在堆上分配, 因为它在 f() 返回后, 依然可以通过包一级的变量 global 访问到. 在 Go 里面, 可以说局部变量 x 从 f() 中逃逸了.

g() 中的变量 *y 在 g() 返回后, 变成不可达状态, 将会被回收. 因此 *y 会被分配到栈上.

虽然 Go 有自动垃圾收集器, 但这并不意味着我们可以完全不考虑内存了. 虽然我们不需要显式地分配和释放内存, 但要编写高效的程序, 依然要了解变量的生命周期. 如果把指向短生命周期对象的指针保存到具有长生命周期的对象中, 特别是保存到全局变量中时, 会阻止对短生命周期对象的垃圾回收, 从而影响程序性能.

赋值

Go 中支持 ++ 和 -- 语句.

元组赋值

元组赋值: i, j, k = 2, 3, 5

函数返回多个值

如果函数的返回值有多个, 则在元组赋值的右边, 并且不能再有其他表达式, 只能有一个函数. 如:

f, err = os.Open("foo.txt")

可赋值性

赋值语句是显式的赋值形式, 但是在程序中也可以有隐式的赋值行为:

medals := []string{"gold", "silver", "bronze"}

类似这样写:

medals[0] = "gold" 
medals[1] = "silver" 
medals[2] = "bronze"

类型

编译器根据变量或表达式的类型来分配该变量的内存大小.

同一个类型, 在不同的场景, 有不同的意义. 如 int 类型的变量, 可以用来表示时间, 索引, 等等.

我们可以用类型声明语句创建一个新的类型名称, 和现有类型有相同的底层结构. 声明形式为:

type 类型名字 底层名称

// 如
type Myfloat float64

另外, 要注意, 如果声明了两个 float64 的新类型名称, 尽管它们的底层类型都是 float64, 但这两种类型的变量是不能直接比较的或者直接用在一个表达式中的. 要想比较, 或在一个表达式中使用, 我们需要显式转换.

对每个类型 T(这边的类型, 指的是我们定义的新类型), 都会有一个类型转换操作 T(x), 用于将 x 转成 T 类型. 只有两个类型的底层基础类型相同时, 或者两者都是指向相同底层结构的指针类型, 才允许这种转型操作.

新类型的算术运算行为和底层类型是一致的.

用类型声明语句创建新类型, 一般用在底层类型是复杂类型(如匿名的结构体定义变量)的情况, 这可以避免我们一遍遍地书写复杂类型.

定义新行为

我们还可以给这些用类型声明语句创建的新类型定义新的行为. 这些行为与该类型相关, 我们称之为类型的方法集. 如:

type Celsius float64
func (c Celsius) String() string { return fmt.Sprintf("%g", c) }

注意该方法的声明语句, Celsius 类型的参数 c 出现在函数名 String() 前面, 表示这是一个 Celsius 类型的方法.

如果这个类型和底层类型都定义了 String(), 则在调用时, 如果没有特别指明, 会优先调用该类型定义的方法, 而不是底层类型的.

包和文件

Go 语言中, 包和其他语言中的库或模块的概念类似, 目的都是为了支持模块化, 封装, 单独编译和代码重用. 它还可以让我们通过控制哪些名字是外部可见的, 来隐藏内部实现细节. 在 Go 语言中, 如果名字以大写字母开头, 则说明该名字是可导出的.

包的源码保存在一个或多个以 .go 为文件后缀名的源文件中.

例子

假设我们要开发一个包, 并且发布到 Go 语言社区.

首先创建一个名为 gopl.io/ch2/tempconv 的包. 包的代码存储在两个源文件中, 我们使用这两个源文件来演示, 如何在一个源文件中进行声明, 在另一个源文件中访问.

在包里, 创建一个名为 tempconv.go 的文件, 把变量的声明, 对应的常量都放在这里:

package tempconv

import "fmt"

type Celsius float64
type Fahrenheit float64

const (
    AbsoluteZeroC Celsius = -273.15
    FreezingC     Celsius = 0
    BoilingC      Celsius = 100
)

func (c Celsius) String() string    { return fmt.Sprintf("%g°C", c) }
func (f Fahrenheit) String() string { return fmt.Sprintf("%g°F", f) }

再创建一个名为 conv.go 的源文件, 使用上面声明的新类型:

package tempconv

// CToF converts a Celsius temperature to Fahrenheit.
func CToF(c Celsius) Fahrenheit { return Fahrenheit(c*9/5 + 32) }

// FToC converts a Fahrenheit temperature to Celsius.
func FToC(f Fahrenheit) Celsius { return Celsius((f - 32) * 5 / 9) }

可以看出, 每个源文件开头都要声明该文件所属的包.

当我们导入包的时候, 包内的成员可以通过类似 tempconv.CToF 的形式来访问.

在包级别声明的变量, 可以在同一个包的其他源文件中直接使用, 如 tempconv.go 文件中声明了类型 Celsius, 在 conv.go 文件中可以直接使用.

在其他包中使用 tempconv 包时, 先导入该包, 再调用里面的方法:

import "gopl.io/ch2/tempconv"
...
fmt.Println(tempconv.CToF(tempconv.BoilingC))

包的初始化

在包内, 可以声明若干个 init() 来做初始化工作. init() 除了不能被调用或引用外, 其他行为和普通函数类似.

每个包以导入声明的顺序初始化, 每个包只会被初始化一次. init() 会在 main() 之前执行.

作用域

一个声明语句将程序中的实体和一个名字关联, 作用域是指源代码中可以有效使用这个名字的范围.

作用域和生命周期是两个概念. 作用域对应的是一个源代码的文本区域, 是编译时的概念; 生命周期是指变量存在的有效时间段, 在此时间区域内它可以被程序的其他部分引用, 是运行时的概念.

一个程序可能包含多个同名的声明, 但只要它们在不同的词法域, 就没有问题. 如, 可以声明一个局部变量, 和包级的变量同名. 当编译器遇到一个名字引用时, 如果是个声明, 它首先从最内层的词法域向全局的作用域查找, 如果查找失败, 则报告错误, 如果这个名字在内部和外部的块中都有声明过, 则内部的声明首先被找到. 在这种情况下, 内部声明屏蔽了外部同名的声明, 使得外部声明的名字无法被访问.

Comments

使用 Disqus 评论
comments powered by Disqus